Add validation to opening URLs in external desktop handler (fix #1459)

This commit is contained in:
Ronan Jouchet 2022-09-17 22:22:19 -04:00
parent eecc33c1aa
commit 840fe4a199
10 changed files with 204 additions and 39 deletions

View File

@ -18,7 +18,7 @@ export function initContextMenu(
options: OutputOptions, options: OutputOptions,
window?: BrowserWindow, window?: BrowserWindow,
): void { ): void {
log.debug('initContextMenu', { options, window }); log.debug('initContextMenu');
// eslint-disable-next-line @typescript-eslint/no-unsafe-call // eslint-disable-next-line @typescript-eslint/no-unsafe-call
contextMenu({ contextMenu({

View File

@ -45,7 +45,7 @@ export function createMenu(
options: OutputOptions, options: OutputOptions,
mainWindow: BrowserWindow, mainWindow: BrowserWindow,
): void { ): void {
log.debug('createMenu', { options, mainWindow }); log.debug('createMenu', { options });
const menuTemplate = generateMenu(options, mainWindow); const menuTemplate = generateMenu(options, mainWindow);
injectBookmarks(menuTemplate); injectBookmarks(menuTemplate);

View File

@ -1,9 +1,14 @@
import { shell } from 'electron';
jest.mock('./windowHelpers');
import { import {
linkIsInternal,
getCounterValue,
removeUserAgentSpecifics,
cleanupPlainText, cleanupPlainText,
getCounterValue,
linkIsInternal,
openExternal,
removeUserAgentSpecifics,
} from './helpers'; } from './helpers';
import { showNavigationBlockedMessage } from './windowHelpers';
const internalUrl = 'https://medium.com/'; const internalUrl = 'https://medium.com/';
const internalUrlWww = 'https://www.medium.com/'; const internalUrlWww = 'https://www.medium.com/';
@ -285,3 +290,59 @@ describe('cleanupPlainText', () => {
expect(cleanupPlainText(' this is a test ')).toBe('this is a test'); expect(cleanupPlainText(' this is a test ')).toBe('this is a test');
}); });
}); });
describe('openExternal', () => {
const mockShellOpenExternal: jest.SpyInstance = jest.spyOn(
shell,
'openExternal',
);
const mockShowNavigationBlockedMessage: jest.SpyInstance =
showNavigationBlockedMessage as jest.Mock;
beforeEach(() => {
mockShellOpenExternal.mockReset();
mockShowNavigationBlockedMessage
.mockReset()
.mockReturnValue(Promise.resolve(undefined));
});
afterAll(() => {
mockShellOpenExternal.mockRestore();
mockShowNavigationBlockedMessage.mockRestore();
});
test('https urls scheme should *not* be blocked', async () => {
await openExternal('https://whatever.foo');
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockShellOpenExternal).toHaveBeenCalled();
});
test('urls with whitelisted scheme should *not* be blocked', async () => {
await openExternal('ircs://irc.libera.chat/whatever');
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockShellOpenExternal).toHaveBeenCalled();
});
test('urls with non-allowlisted scheme *should* be blocked', async () => {
await openExternal('barf://whatever.foo');
expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockShellOpenExternal).not.toHaveBeenCalled();
});
test('potentially-malicious urls *should* be blocked', async () => {
await openExternal('https://hello.com/wor%00ld');
expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockShellOpenExternal).not.toHaveBeenCalled();
});
test('malformed urls *should* be blocked', async () => {
await openExternal('zombocom');
expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockShellOpenExternal).not.toHaveBeenCalled();
});
});

View File

@ -5,9 +5,80 @@ import * as path from 'path';
import { BrowserWindow, OpenExternalOptions, shell } from 'electron'; import { BrowserWindow, OpenExternalOptions, shell } from 'electron';
import * as log from '../helpers/loggingHelper'; import * as log from '../helpers/loggingHelper';
import { showNavigationBlockedMessage } from './windowHelpers';
export const INJECT_DIR = path.join(__dirname, '..', 'inject'); export const INJECT_DIR = path.join(__dirname, '..', 'inject');
// Taken from Firefox's. Location might vary in codebase, search for one of them, e.g.
// https://searchfox.org/mozilla-central/search?q=%22xmpp%22&path=&case=false&regexp=false
const SAFE_URL_PROTOCOLS_FIREFOX = [
'bitcoin:',
'ftp:',
'ftps:',
'geo:',
'im:',
'irc:',
'ircs:',
'magnet:',
'mailto:',
'matrix:',
'mms:',
'news:',
'nntp:',
'openpgp4fpr:',
'sftp:',
'sip:',
'sms:',
'smsto:',
'ssh:',
'tel:',
'urn:',
'webcal:',
'wtai:',
'xmpp:',
];
const SAFE_URL_PROTOCOLS = ['http:', 'https:', ...SAFE_URL_PROTOCOLS_FIREFOX];
const SHELL_SAFETY_FEEDBACK_STR =
'If you believe this URL should open, you might be right, and our validation might be excessive.' +
'Please share this error & URL at https://github.com/nativefier/nativefier/issues/1459';
export function isUrlShellSafe(
urlToGo: string,
): { blocked: false } | { blocked: true; reason: string } {
let url: URL;
try {
url = new URL(urlToGo.toLowerCase());
} catch (err: unknown) {
return {
blocked: true,
reason: `URL appears malformed. ${SHELL_SAFETY_FEEDBACK_STR}`,
};
}
if (!SAFE_URL_PROTOCOLS.includes(url.protocol)) {
return {
blocked: true,
reason: `URL protocol is disallowed. ${SHELL_SAFETY_FEEDBACK_STR}`,
};
}
// https://cwe.mitre.org/data/definitions/177.html
if (
urlToGo.includes('%00') ||
urlToGo.includes('%0a') ||
urlToGo.includes('%2e') ||
urlToGo.includes('%2f') ||
urlToGo.includes('%5c')
) {
return {
blocked: true,
reason: `URL might be malicious. ${SHELL_SAFETY_FEEDBACK_STR}`,
};
}
return { blocked: false };
}
/** /**
* Helper to print debug messages from the main process in the browser window * Helper to print debug messages from the main process in the browser window
*/ */
@ -166,11 +237,28 @@ export function nativeTabsSupported(): boolean {
return isOSX(); return isOSX();
} }
/**
* Open the given external protocol URL in the desktop's default manner
* (e.g. `mailto:` URLs in the user's default mail agent), with extra validation.
*/
export function openExternal( export function openExternal(
url: string, url: string,
options?: OpenExternalOptions, options?: OpenExternalOptions,
): Promise<void> { ): Promise<void> {
log.debug('openExternal', { url, options }); const urlShellSafety = isUrlShellSafe(url);
log.debug('openExternal', { url, options, urlShellSafety });
if (urlShellSafety.blocked) {
return new Promise((resolve) => {
showNavigationBlockedMessage(
`Navigation blocked to ${url}\n\n${urlShellSafety.reason}`,
)
.then(() => resolve())
.catch((err: unknown) => {
throw err;
});
});
}
return shell.openExternal(url, options); return shell.openExternal(url, options);
} }

View File

@ -31,7 +31,7 @@ const {
onWillPreventUnload: (event: unknown) => void; onWillPreventUnload: (event: unknown) => void;
} = jest.requireActual('./windowEvents'); } = jest.requireActual('./windowEvents');
import { import {
blockExternalURL, showNavigationBlockedMessage,
createAboutBlankWindow, createAboutBlankWindow,
createNewTab, createNewTab,
} from './windowHelpers'; } from './windowHelpers';
@ -49,7 +49,8 @@ describe('onNewWindowHelper', () => {
targetUrl: originalURL, targetUrl: originalURL,
zoom: 1.0, zoom: 1.0,
}; };
const mockBlockExternalURL: jest.SpyInstance = blockExternalURL as jest.Mock; const mockShowNavigationBlockedMessage: jest.SpyInstance =
showNavigationBlockedMessage as jest.Mock;
const mockCreateAboutBlank: jest.SpyInstance = const mockCreateAboutBlank: jest.SpyInstance =
createAboutBlankWindow as jest.Mock; createAboutBlankWindow as jest.Mock;
const mockCreateNewTab: jest.SpyInstance = createNewTab as jest.Mock; const mockCreateNewTab: jest.SpyInstance = createNewTab as jest.Mock;
@ -63,7 +64,7 @@ describe('onNewWindowHelper', () => {
const setupWindow = jest.fn(); const setupWindow = jest.fn();
beforeEach(() => { beforeEach(() => {
mockBlockExternalURL mockShowNavigationBlockedMessage
.mockReset() .mockReset()
.mockReturnValue(Promise.resolve(undefined)); .mockReturnValue(Promise.resolve(undefined));
mockCreateAboutBlank.mockReset(); mockCreateAboutBlank.mockReset();
@ -76,7 +77,7 @@ describe('onNewWindowHelper', () => {
}); });
afterAll(() => { afterAll(() => {
mockBlockExternalURL.mockRestore(); mockShowNavigationBlockedMessage.mockRestore();
mockCreateAboutBlank.mockRestore(); mockCreateAboutBlank.mockRestore();
mockCreateNewTab.mockRestore(); mockCreateNewTab.mockRestore();
mockLinkIsInternal.mockRestore(); mockLinkIsInternal.mockRestore();
@ -95,7 +96,7 @@ describe('onNewWindowHelper', () => {
expect(mockCreateAboutBlank).not.toHaveBeenCalled(); expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled(); expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled(); expect(preventDefault).not.toHaveBeenCalled();
}); });
@ -113,7 +114,7 @@ describe('onNewWindowHelper', () => {
expect(mockCreateAboutBlank).not.toHaveBeenCalled(); expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled(); expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).toHaveBeenCalledTimes(1); expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });
@ -134,7 +135,7 @@ describe('onNewWindowHelper', () => {
expect(mockCreateAboutBlank).not.toHaveBeenCalled(); expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled(); expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockBlockExternalURL).toHaveBeenCalledTimes(1); expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });
@ -150,7 +151,7 @@ describe('onNewWindowHelper', () => {
expect(mockCreateAboutBlank).not.toHaveBeenCalled(); expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled(); expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled(); expect(preventDefault).not.toHaveBeenCalled();
}); });
@ -168,7 +169,7 @@ describe('onNewWindowHelper', () => {
expect(mockCreateAboutBlank).not.toHaveBeenCalled(); expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled(); expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).toHaveBeenCalledTimes(1); expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });
@ -193,7 +194,7 @@ describe('onNewWindowHelper', () => {
true, true,
undefined, undefined,
); );
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });
@ -218,7 +219,7 @@ describe('onNewWindowHelper', () => {
false, false,
undefined, undefined,
); );
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });
@ -234,7 +235,7 @@ describe('onNewWindowHelper', () => {
expect(mockCreateAboutBlank).toHaveBeenCalledTimes(1); expect(mockCreateAboutBlank).toHaveBeenCalledTimes(1);
expect(mockCreateNewTab).not.toHaveBeenCalled(); expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });
@ -250,7 +251,7 @@ describe('onNewWindowHelper', () => {
expect(mockCreateAboutBlank).toHaveBeenCalledTimes(1); expect(mockCreateAboutBlank).toHaveBeenCalledTimes(1);
expect(mockCreateNewTab).not.toHaveBeenCalled(); expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });
@ -266,7 +267,7 @@ describe('onNewWindowHelper', () => {
expect(mockCreateAboutBlank).not.toHaveBeenCalled(); expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled(); expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled(); expect(preventDefault).not.toHaveBeenCalled();
}); });
@ -277,13 +278,14 @@ describe('onWillNavigate', () => {
const internalURL = 'https://medium.com/topics/technology'; const internalURL = 'https://medium.com/topics/technology';
const externalURL = 'https://www.wikipedia.org/wiki/Electron'; const externalURL = 'https://www.wikipedia.org/wiki/Electron';
const mockBlockExternalURL: jest.SpyInstance = blockExternalURL as jest.Mock; const mockShowNavigationBlockedMessage: jest.SpyInstance =
showNavigationBlockedMessage as jest.Mock;
const mockLinkIsInternal: jest.SpyInstance = linkIsInternal as jest.Mock; const mockLinkIsInternal: jest.SpyInstance = linkIsInternal as jest.Mock;
const mockOpenExternal: jest.SpyInstance = openExternal as jest.Mock; const mockOpenExternal: jest.SpyInstance = openExternal as jest.Mock;
const preventDefault = jest.fn(); const preventDefault = jest.fn();
beforeEach(() => { beforeEach(() => {
mockBlockExternalURL mockShowNavigationBlockedMessage
.mockReset() .mockReset()
.mockReturnValue(Promise.resolve(undefined)); .mockReturnValue(Promise.resolve(undefined));
mockLinkIsInternal.mockReset().mockReturnValue(false); mockLinkIsInternal.mockReset().mockReturnValue(false);
@ -292,7 +294,7 @@ describe('onWillNavigate', () => {
}); });
afterAll(() => { afterAll(() => {
mockBlockExternalURL.mockRestore(); mockShowNavigationBlockedMessage.mockRestore();
mockLinkIsInternal.mockRestore(); mockLinkIsInternal.mockRestore();
mockOpenExternal.mockRestore(); mockOpenExternal.mockRestore();
}); });
@ -306,7 +308,7 @@ describe('onWillNavigate', () => {
const event = { preventDefault }; const event = { preventDefault };
await onWillNavigate(options, event, internalURL); await onWillNavigate(options, event, internalURL);
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled(); expect(preventDefault).not.toHaveBeenCalled();
}); });
@ -319,12 +321,12 @@ describe('onWillNavigate', () => {
const event = { preventDefault }; const event = { preventDefault };
await onWillNavigate(options, event, externalURL); await onWillNavigate(options, event, externalURL);
expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).toHaveBeenCalledTimes(1); expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });
test('external urls should be ignored if blockExternalUrls is true', async () => { test('external urls should be blocked if blockExternalUrls is true', async () => {
const options = { const options = {
blockExternalUrls: true, blockExternalUrls: true,
targetUrl: originalURL, targetUrl: originalURL,
@ -332,7 +334,7 @@ describe('onWillNavigate', () => {
const event = { preventDefault }; const event = { preventDefault };
await onWillNavigate(options, event, externalURL); await onWillNavigate(options, event, externalURL);
expect(mockBlockExternalURL).toHaveBeenCalledTimes(1); expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockOpenExternal).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
}); });

View File

@ -9,12 +9,12 @@ import {
import { linkIsInternal, nativeTabsSupported, openExternal } from './helpers'; import { linkIsInternal, nativeTabsSupported, openExternal } from './helpers';
import * as log from './loggingHelper'; import * as log from './loggingHelper';
import { import {
blockExternalURL,
createAboutBlankWindow, createAboutBlankWindow,
createNewTab, createNewTab,
injectCSS, injectCSS,
sendParamsOnDidFinishLoad, sendParamsOnDidFinishLoad,
setProxyRules, setProxyRules,
showNavigationBlockedMessage,
} from './windowHelpers'; } from './windowHelpers';
import { WindowOptions } from '../../../shared/src/options/model'; import { WindowOptions } from '../../../shared/src/options/model';
@ -86,7 +86,9 @@ export function onNewWindowHelper(
preventDefault(); preventDefault();
if (options.blockExternalUrls) { if (options.blockExternalUrls) {
return new Promise((resolve) => { return new Promise((resolve) => {
blockExternalURL(urlToGo) showNavigationBlockedMessage(
`Navigation to external URL blocked by options: ${urlToGo}`,
)
.then(() => resolve()) .then(() => resolve())
.catch((err: unknown) => { .catch((err: unknown) => {
throw err; throw err;
@ -129,7 +131,7 @@ export function onWillNavigate(
event: Event, event: Event,
urlToGo: string, urlToGo: string,
): Promise<void> { ): Promise<void> {
log.debug('onWillNavigate', { options, event, urlToGo }); log.debug('onWillNavigate', urlToGo);
if ( if (
!linkIsInternal( !linkIsInternal(
options.targetUrl, options.targetUrl,
@ -141,7 +143,9 @@ export function onWillNavigate(
event.preventDefault(); event.preventDefault();
if (options.blockExternalUrls) { if (options.blockExternalUrls) {
return new Promise((resolve) => { return new Promise((resolve) => {
blockExternalURL(urlToGo) showNavigationBlockedMessage(
`Navigation to external URL blocked by options: ${urlToGo}`,
)
.then(() => resolve()) .then(() => resolve())
.catch((err: unknown) => { .catch((err: unknown) => {
throw err; throw err;

View File

@ -23,12 +23,14 @@ export function adjustWindowZoom(adjustment: number): void {
}); });
} }
export function blockExternalURL(url: string): Promise<MessageBoxReturnValue> { export function showNavigationBlockedMessage(
message: string,
): Promise<MessageBoxReturnValue> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
withFocusedWindow((focusedWindow) => { withFocusedWindow((focusedWindow) => {
dialog dialog
.showMessageBox(focusedWindow, { .showMessageBox(focusedWindow, {
message: `Cannot navigate to external URL: ${url}`, message,
type: 'error', type: 'error',
title: 'Navigation blocked', title: 'Navigation blocked',
}) })

View File

@ -438,14 +438,14 @@ app.on(
}, },
); );
app.on('browser-window-blur', (event: Event, window: BrowserWindow) => { app.on('browser-window-blur', () => {
log.debug('app.browser-window-blur', { event, window }); log.debug('app.browser-window-blur');
}); });
app.on('browser-window-created', (event: Event, window: BrowserWindow) => { app.on('browser-window-created', () => {
log.debug('app.browser-window-created', { event, window }); log.debug('app.browser-window-created');
}); });
app.on('browser-window-focus', (event: Event, window: BrowserWindow) => { app.on('browser-window-focus', () => {
log.debug('app.browser-window-focus', { event, window }); log.debug('app.browser-window-focus');
}); });

View File

@ -144,10 +144,17 @@ class MockWebRequest {
class InternalEmitter extends EventEmitter {} class InternalEmitter extends EventEmitter {}
const mockShell = {
openExternal(url: string, options?: unknown): Promise<void> {
return new Promise((resolve) => resolve());
},
};
export { export {
MockDialog as dialog, MockDialog as dialog,
MockBrowserWindow as BrowserWindow, MockBrowserWindow as BrowserWindow,
MockSession as Session, MockSession as Session,
MockWebContents as WebContents, MockWebContents as WebContents,
MockWebRequest as WebRequest, MockWebRequest as WebRequest,
mockShell as shell,
}; };

View File

@ -52,6 +52,7 @@
"test:noplaywright": "jest --testPathIgnorePatterns=playwright", "test:noplaywright": "jest --testPathIgnorePatterns=playwright",
"test:unit": "jest", "test:unit": "jest",
"test:watch": "echo 'Remember to run npm run build:watch for the test watcher to work!' && jest --watchAll --collectCoverage=false", "test:watch": "echo 'Remember to run npm run build:watch for the test watcher to work!' && jest --watchAll --collectCoverage=false",
"test:watch:unit": "echo 'Remember to run npm run build:watch for the test watcher to work!' && jest --watchAll --collectCoverage=false --testPathIgnorePatterns=integration --testPathIgnorePatterns=playwright",
"test:withlog": "LOGLEVEL=trace npm run test", "test:withlog": "LOGLEVEL=trace npm run test",
"test": "jest", "test": "jest",
"watch": "npx concurrently \"npm:*:watch\"" "watch": "npx concurrently \"npm:*:watch\""